该内容已被发布者删除 该内容被自由微信恢复
文章于 2022年4月29日 被检测为删除。
查看原文
被用户删除
其他

这个支持断点续传的个人网盘系统有点大,你忍一下~

点击关注 👉 鸭哥聊Java 2022-04-29

大家好,我是鸭哥。


功能设计


  1. 登录鉴权:进入系统必须先登录,未登录无法访问到后端接口与网盘的静态资源

  2. 上传:断点续传、文件秒传

  3. 文件分享:生成一个随机密钥字符串与一个资源访问地址,输入密钥验证成功即可访问到该资源,密钥会在一定时间内过期

  4. 回收站:删除后的文件默认先保留在回收站,7天后自动删除

  5. 文件操作: 新建文件夹、重命名、移动、删除、批量删除


技术选型


  • 前端:使用Vue构建,使用ElementUI构建UI,使用vue-simple-uploader插件实现上传的断点续传、文件秒传功能。

  • 后端:使用Koa实现,直接使用Koa搭建静态资源服务器(即个人网盘资源目录),加入静态资源鉴权,使用原生Nodejs处理文件管理与上传功能。


问题与思考


Q:是否需要使用数据库,将文件信息保存到数据库中?


原则上,文件的增删查改都将使用原生nodejs进行操作,这些都不需要使用到数据库。但是原生nodejs并不能直接读取到文件的MD5值,在断点续传与秒传功能中就无法通过传来的MD5标识跟本地的文件进行匹配。所以还是需要建立一个含文件MD5、文件路径等信息的数据表记录本地文件的MD5。


Q:若使用了数据库记录文件MD5信息,怎么保证数据表的数据与本地物理存储是同步的?


如果进行文件操作并不是通过该文件管理系统,而是直接在windows上进入到网盘目录进行文件增删改,这时我们的应用是无法监听到文件的变更的,数据表数据并不会更新。这样就会出现我把某个文件删除了,但是数据表仍然记录了该文件是已经上传的情况。


原本是想采用使用定时器定时对本地文件与数据表进行数据同步,但是发现这样在后期文件多或嵌套深的情况下性能会很差,这种方式并不合适。


由于这些信息只是在文件断点续传与秒传功能中需要用到,后面采用的方案为:直接在预探请求中先判断数据库信息是否与本地物理存储相符,如果不相符则认为本地已不存在,需要重新上传。(原则上是不推荐直接使用windows进入目录进行文件操作,而是都通过这个文件管理系统进行文件操作)


Q: 同一个文件,但存在于网盘不同目录下,同时在不同目录删除该文件,回收站中是否会冲突?


删除文件时,使用原文件名+时间(yyyy-MM-dd HH:mm:ss)进行重命名后再移动文件到回收站。同时需要往数据库记录文件删除的信息,删除前的文件路径与删除时间等,以便实现文件还原与回收站定时清理的功能。


Q:文件夹并无MD5值,删除文件夹如何确保可以还原?


删除文件夹与删除文件属于同样的操作,也是通过文件夹名+时间重命名后移动到回收站目录。但是数据库中需要使用一个新的数据表记录文件夹的删除信息。


实现


文件鉴权


登录时保留session, 然后使用一个中间件鉴权,如果没有session则不允许访问系统除登录接口外的其他任何请求,包括静态资源。使用koa-static构建静态资源服务器,并将defer属性设置为true,让它允许通过鉴权中间件。

// ...app.use(async (ctx, next) => { if (ctx.url.includes('/storage') && ctx.url !== '/storage/login') { if (!ctx.session.isLogin) { ctx.body = r.loginError() return } } await next()})
// ...app.use(static(__dirname + '/public', { defer: true}))
// ...const router = new Router({ prefix: '/storage'})
// ...router.post('/login', async ctx => { const { access } = ctx.request.body if (!access) { ctx.body = r.parameterError() return } try { const base64Decode = new Buffer.from(access, 'base64') const genAccess = base64Decode.toString() if (storageRootKey !== genAccess) { ctx.body = r.error(311, '密码错误') return } ctx.session.isLogin = true logger('登入Storage') ctx.body = r.success() } catch (e) { ctx.body = r.error(310, '登录失败') }})

这里设置的文件系统接口为storage/*,静态资源服务器为public/storage,登录时前后端会把密码进行简单base64转码。


若未登录直接访问静态资源,则回返回错误信息。


未登录直接访问



登录后再访问



断点续传与文件秒传


文件md5计算


实现断点续传与文件秒传的前提是需要确定出文件的唯一标识,最好的方式是计算出文件的md5值。


由于选择的vue-simple-uploader没有直接提供文件md5计算的api,因此需要手动实现。这里采用spark-md5插件计算文件的md5,在file-added事件中,直接用fileReader读取文件,根据每个切片循环算出md5。


注意尽量不要直接一次读取整个文件的md5,直接读取大文件在IE浏览器中有可能会出现卡死的情况,遍历读取每个切片可以减轻浏览器计算压力。

methods: { hanldeFileAdd (file) { const fileList = this.$refs.uploader.files const index = fileList.findIndex(item => item.name === file.name) if (~index) { file.removeFile(file) } else { file.targetPath = this.currentPath this.computeMD5(file) } }, computeMD5 (file) { const fileReader = new FileReader() const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice let currentChunk = 0 const chunkSize = CHUNK_SIZE const chunks = Math.ceil(file.size / chunkSize) const spark = new SparkMD5.ArrayBuffer() this.$nextTick(() => { this.createMD5Element(file) }) loadNext() fileReader.onload = e => { spark.append(e.target.result) if (currentChunk < chunks) { currentChunk++ loadNext() this.$nextTick(() => { this.setMD5ElementText(file, `校验MD5 ${((currentChunk / chunks) * 100).toFixed(0)}%`) document.querySelector(`.uploader-list .file-${file.id} .uploader-file-actions`).style.display = 'none' }) } else { const md5 = spark.end() file.uniqueIdentifier = md5 file.resume() this.destoryMD5Element(file) document.querySelector(`.uploader-list .file-${file.id} .uploader-file-actions`).style.display = 'block' } } fileReader.onerror = function () { this.$nextTick(() => { this.setMD5ElementText(file, '校验MD5失败') }) file.cancel() } function loadNext () { const start = currentChunk * chunkSize const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end)) } }, createMD5Element (file) { this.$nextTick(() => { const el = document.querySelector(`.uploader-list .file-${file.id} .uploader-file-status`) const MD5Status = document.createElement('div') MD5Status.setAttribute('class', 'md5-status') el.appendChild(MD5Status) }) }, destoryMD5Element (file) { this.$nextTick(() => { const el = document.querySelector(`.uploader-list .file-${file.id} .uploader-file-status .md5-status`) if (el) { el.parentNode.removeChild(el) } }) }, setMD5ElementText (file, text) { const el = document.querySelector(`.uploader-list .file-${file.id} .uploader-file-status .md5-status`) if (el) { el.innerText = text } }}

将计算完的MD5直接替换到file对象的uniqueIdentifier属性上,最终发送的请求中的identifier将是文件的MD5,后端通过该字段进行识别。

Vue-simple-uploader文件列表状态需要加入计算MD5相关状态,可以通过css为原文件列表增加多一层md5状态层,然后通过相关事件进行显隐。

对于一些大文件,如果遍历完整个文件进行分片计算md5会比较慢,这时候可以才有跳分片的方法,只计算第一片、最后一片和中间间隔一定数量分片才计算。可以加快计算md5的速度。


断点续传


默认Vue-simple-uploader提供了文件上传时的暂停/开始操作,你可以在上传过程中随时暂停。但是这个并不是真正的断点续传,因为页面刷新后,上传状态并没有保存下来,仍会重新从第一片重新上传。若将状态保留到localstorage中,仍是不太现实的,最好的方式是由后端返回是否需要当前这个切片,因为后端能知道当前该文件已上传的切片。


testChunks属性设为true(默认)时,每个切片会先发送一个不含文件流的预探get请求给后端,通过后端返回的http状态码(可更改)判断该切片是否需要发送。


默认每个切片都会发送一个预探请求,这样假如一个10个切片的文件就会产生20个请求,造成浪费。最理想的情况是预探请求只发送一个。新版simple-uplder也考虑到这点,并提供了checkChunkUploadedByResponse属性,可以将预探请求设置为一个,后端为这个预探请求直接返回当前已经有的切片数组,然后前端直接判断切片请求是否需要发送。


例:文件上传到一半,点了暂停,然后刷新网页,再重新上传。文件校验完Md5后,预探请求返回已存在的切片数组[1~25],然后真正切片请求会直接从第26片开始上传。



前端处理

// 前端vue-simple-uploader配置项options: { target: (instance, chunk, isTest) => isTest ? '/api/storage/testUpload' : '/api/storage/upload', query: () => { return { targetPath: this.currentPath } }, chunkSize: CHUNK_SIZE, allowDuplicateUploads: false, checkChunkUploadedByResponse: (chunk, message) => { const response = JSON.parse(message) const existChunk = response.data.map(item => ~~item) return existChunk.includes(chunk.offset + 1) }}

其中/storage/testUpload为预探请求(get),/storage/upload为真正切片上传请求(post)。


checkChunkUploadedByResponse控制只上传后端不存在的切片。


后端处理

router.get('/testUpload', async ctx => { const { identifier, filename, targetPath = '$Root', totalChunks } = ctx.query const chunkFolderURL = `${storageChunkPath}/${identifier}` try { const checkExistResult = await query(`select * from storage where id = ? and isComplete = 1 and isDel = 0`, identifier) // 检查是否已经完整上传过该文件 if (checkExistResult.length > 0) { let { fullPath } = checkExistResult[0] let realPath = fullPath.replace('$Root', storageRootPath) // 检查当前DB信息是否与物理存储相符 if (fs.existsSync(realPath)) { // 检查目标位置是否与之前上传的位置一样,不一致则复制过去 let targetFilePath = `${targetPath}/${filename}` if (fullPath !== targetFilePath) { targetFilePath = targetFilePath.replace('$Root', storageRootPath) fs.copyFileSync(realPath, targetFilePath) } // 返回全部分片数组 const chunksArr = Array.from({ length: totalChunks }, (item, index) => index + 1) ctx.body = r.successData(chunksArr) return } } if (!fs.existsSync(chunkFolderURL)) { fs.mkdirSync(chunkFolderURL, { recursive: true }) const now = DateFormat(new Date(), 'yyyy-MM-dd HH:mm:ss') const sql = `replace into storage(id, fullPath, updatedTime, isComplete, isDel) values(?, ?, ?, 0, 0)` await query(sql, [identifier, `${targetPath}/${filename}`, now]) ctx.body = r.successData([]) } else { const ls = fs.readdirSync(chunkFolderURL) ctx.body = r.successData(ls) } } catch (e) { ctx.status = 501 ctx.body = r.error(306, e) }})
router.post('/upload', async ctx => { const { chunkNumber, identifier, filename, totalChunks, targetPath = '$Root' } = ctx.request.body const { file } = ctx.request.files const chunkFolderURL = `./public/storage-chunk/${identifier}` const chunkFileURL = `${chunkFolderURL}/${chunkNumber}` if (chunkNumber !== totalChunks) { const reader = fs.createReadStream(file.path) const upStream = fs.createWriteStream(chunkFileURL) reader.pipe(upStream) ctx.body = r.success() } else { const targetFile = `${targetPath}/${filename}`.replace('$Root', storageRootPath) fs.writeFileSync(targetFile, '') try { for (let i = 1; i <= totalChunks; i++) { const url = i == totalChunks ? file.path : `${chunkFolderURL}/${i}` const buffer = fs.readFileSync(url) fs.appendFileSync(targetFile, buffer) } const now = DateFormat(new Date(), 'yyyy-MM-dd HH:mm:ss') const sql = `update storage set isComplete = 1, updatedTime = ? where id = ?` await query(sql, [now, identifier]) ctx.body = r.success() deleteFolder(chunkFolderURL) logger('文件上传成功', 1, `targetFile: ${targetFile}, MD5:${identifier}, 切片源删除成功`) } catch (e) { ctx.status = 501 ctx.body = r.error(501, e) logger('文件合并失败', 0, `分片丢失 => ${e}`) fs.unlinkSync(targetFile) } }})

在testUpload请求中,通过数据库与本地切片生成已存在的切片数组给前端,若从未传过还需要更新数据库记录。


在upload请求中,对每个切片使用nodejs管道流进行读写,将文件保留在chunk文件夹中,并以md5值为文件名,存放目标文件的切片。当遇到最后一个切片时,执行合并文件操作(需要注意,最后一个切片由于流未关闭,这个时刻最后一个切片文件是还没保存到本地,只是可以直接读取临时文件)。合并文件完成后,删除切片文件夹,并更新数据库信息,记录该文件已经完成。



当上传一个本地已经存在的文件时,由于数据库记录了该md5文件是已经完成的,所以预探请求会返回全部切片数组,前端就不会再发送upload请求从而实现了文件秒传。即使上传的目标目录与本地已存在文件处在不同目录,在预探请求时识别到时,也会进行复制操作,前端也不需要再传。


断点续传演示



上传过程暂停,然后刷新页面,重新上传同一个文件,可以发现文件是从上传暂停的地方重新开始。


文件秒传演示



上传上面演示的同一个文件,由于发现是已经存在的文件,则会直接返回成功。


至此,一个断点续传、秒传功能的前后端都实现完了。


另外该系统还有一些对文件进行移动、删除、下载的功能都是比较简单的,基本都是使用nodejs的fs模块就能实现,这里就不细说了。


后端源码


由于目前该后端是嵌入到了本人的其他系统里面,还未能将整个koa后端开源,以下列举部分接口的实现。


  • 获取验证码

const svgCaptcha = require('svg-captcha');...// 获取验证码router.get('/captcha', async ctx => { const c = svgCaptcha.create({ background: '#f5f5f7' }) const captcha = 'data:image/svg+xml;base64,' + new Buffer.from(c.data).toString('base64') ctx.session.captcha = c.text.toLocaleLowerCase() ctx.body = r.successData({ captcha })})

  • 登录

// 登录router.post('/login', async ctx => { const { username, password, captcha } = ctx.request.body if (!username || !password || !captcha) { ctx.body = r.parameterError() return } if (captcha != ctx.session.captcha.toLocaleLowerCase()) { ctx.body = r.error(311, '验证码错误') return } try { const base64Decode = new Buffer.from(password, 'base64') const genPwd = base64Decode.toString() const result = await query(`select * from storage_user where username = ? and password = ?`, [username, genPwd]) if (!result || result.length === 0) { ctx.body = r.error(311, '账号或密码错误') return } ctx.session.user = username logger('登入Storage') ctx.body = r.success() } catch (e) { ctx.body = r.error(310, '登录失败') }})


  • 获取当前目录下文件

// 获取当前目录下文件router.get('/getFileList', async ctx => { const { currentPath = '$Root' } = ctx.query const storageURL = currentPath.replace('$Root', storageRootPath) const ls = fs.readdirSync(storageURL) const infoList = ls.map(item => { const info = fs.statSync(`${storageURL}/${item}`) return { fileName: item, fullPath: `${currentPath}/${item}`, isFolder: info.isDirectory(), size: info.size, updatedTime: DateFormat(new Date(info.mtime), 'yyyy-MM-dd HH:mm:ss') } }) ctx.body = r.successData(infoList)})


  • 重命名

// 重命名router.post('/rename', async ctx => { const { oldPath, newPath } = ctx.request.body if (!oldPath || !newPath) { ctx.body = r.parameterError(); return } const oldRealPath = oldPath.replace('$Root', storageRootPath) const newRealPath = newPath.replace('$Root', storageRootPath) try { await query(`update storage set fullPath = ? where fullPath = ?`, [newPath, oldPath]) fs.renameSync(oldRealPath, newRealPath) ctx.body = r.success() logger('重命名', 1, `${oldPath} => ${newPath}`) } catch (e) { ctx.body = r.error(312, e) logger('重命名', 0, e) }})

  • 删除文件或文件夹

// 删除文件或文件夹router.post('/delete', async ctx => { let { deleteList } = ctx.request.body if (!deleteList || deleteList.length === 0) { ctx.body = r.parameterError(); return } try { await Promise.all( deleteList.map(async item => { const { target, isFolder } = item const oldPath = target.replace('$Root', storageRootPath) // 空文件夹直接删除 if (isFolder) { const ls = fs.readdirSync(oldPath) if (ls.length === 0) { fs.rmdirSync(oldPath) ctx.body = r.success() logger('删除文件或文件夹', 1, `${oldPath}(直接删除)`) return } } try { const time = DateFormat(new Date(), 'yyyyMMddHHmmss') const pathArr = target.split('/') const fileName = pathArr[pathArr.length - 1] let newFileName if (isFolder) { newFileName = `${fileName}-${time}` } else { const fileNameArr = fileName.split('.') const prefix = fileNameArr.length > 1 ? fileNameArr.slice(0, fileNameArr.length - 1).join('.') : fileNameArr[0] const suffix = fileNameArr.length > 1 ? fileNameArr[fileNameArr.length - 1] : '' const dbFileInfo = await query(`select * from storage where fullPath = ?`, target) if (dbFileInfo.length > 0) { newFileName = `${dbFileInfo[0].id}.${suffix}` } else { newFileName = `${prefix}-${time}.${suffix}` } } const newPathArr = pathArr.slice(0, pathArr.length - 1) newPathArr.push(newFileName) const newPath = newPathArr.join('/') // const newRealPath = newPath.replace('$Root', storageTrashPath) const afterStorageTrashPath = `${storageTrashPath}/${newFileName}` if (!fs.existsSync(storageTrashPath)) fs.mkdirSync(storageTrashPath) fs.renameSync(oldPath, afterStorageTrashPath) if (isFolder) { const now = DateFormat(new Date(), 'yyyy-MM-dd HH:mm:ss') const id = 'D' + RandomString(8) await query(`insert into trash_folder(id, folderName, fromPath, updatedTime) values(?, ?, ?, ?)`, [id, newFileName, target, now]) } else { await query(`update storage set isDel = 1 where fullPath = ?`, target) } logger('删除文件或文件夹', 1, `${oldPath}`) return Promise.resolve(1) } catch (e) { logger('删除文件或文件夹', 0, e) return Promise.reject(e) } }) ) ctx.body = r.success() } catch (e) { ctx.body = r.error(308, '操作失败,未知错误') }})


  • 移动或复制

// 移动或复制router.post('/move', async ctx => { const { moveFrom, moveTo, moveType = 0 } = ctx.request.body if (!moveTo || !moveFrom || moveFrom.length === 0) { ctx.body = r.parameterError() return } // try { let sql = `` let paramsArr = [] const moveToRealPath = moveTo.replace('$Root', storageRootPath) moveFrom.map(item => { const moveFromRealPath = item.replace('$Root', storageRootPath) const arr = item.split('/') const fileName = arr[arr.length - 1] if (moveFromRealPath !== `${moveToRealPath}/${fileName}`) { if (moveType === 0) { fs.renameSync(moveFromRealPath, `${moveToRealPath}/${fileName}`) sql += `update storage set fullPath = ? where fullPath = ?;` paramsArr.push(`${moveTo}/${fileName}`, item) } else { const now = DateFormat(new Date(), 'yyyy-MM-dd HH:mm:ss') const id = 'F' + RandomString(8) fs.copyFileSync(moveFromRealPath, `${moveToRealPath}/${fileName}`) sql += `insert into storage(id, md5, fullPath, isComplete, isDel, updatedTime) values(?, (select md5 from storage a where fullPath = ?), ?, 1, 0, ?)` paramsArr.push(id, item, moveTo, now) } } }) if (sql) { await transactionQuery(sql, paramsArr) } ctx.body = r.success() logger(moveType === 0 ? '移动文件' : '复制文件', 1, `MoveFrom: ${moveFrom.join(', ')} => MoveTo: ${moveTo}`)})


  • 新建文件夹

// 新建文件夹router.post('/createFolder', async ctx => { const { folderName } = ctx.request.body if (!folderName) { ctx.body = r.parameterError(); return } let newPath = folderName.replace('$Root', storageRootPath) try { fs.mkdirSync(newPath) ctx.body = r.success() logger('新建文件夹', 1, `${newPath}`) } catch (e) { ctx.body = r.error(312, e) logger('新建文件夹', 0, e) }})


  • 分片上传预探

router.get('/testUpload', async ctx => { const { identifier, filename, targetPath = '$Root', totalChunks } = ctx.query const chunkFolderURL = `${storageChunkPath}/${identifier}` try { const checkExistResult = await query(`select * from storage where md5 = ? and isComplete = 1 and isDel = 0`, identifier) // 检查是否已经完整上传过该文件 if (checkExistResult.length > 0) { let { fullPath } = checkExistResult[0] let realPath = fullPath.replace('$Root', storageRootPath) /* // 检查当前DB信息是否与物理存储相符 if (fs.existsSync(realPath)) { // 检查目标位置是否与之前上传的位置一样,不一致则复制过去 let targetFilePath = `${targetPath}/${filename}` if (fullPath !== targetFilePath) { targetFilePath = targetFilePath.replace('$Root', storageRootPath) fs.copyFileSync(realPath, targetFilePath) } } */ let targetFilePath = `${targetPath}/${filename}` if (fullPath !== targetFilePath) { targetFilePath = targetFilePath.replace('$Root', storageRootPath) fs.copyFileSync(realPath, targetFilePath) } if (!fs.existsSync(realPath)) { const now = DateFormat(new Date(), 'yyyy-MM-dd HH:mm:ss') const id = 'F' + RandomString(8) const sql = `insert into storage(id, md5, fullPath, updatedTime, isComplete, isDel) values(?, ?, ?, ?, 1, 0)` await query(sql, [id, identifier, targetFilePath, now]) } ctx.body = r.successData(Array.from({ length: totalChunks }, (item, index) => ~~index + 1)) return } if (!fs.existsSync(chunkFolderURL)) { fs.mkdirSync(chunkFolderURL, { recursive: true }) const now = DateFormat(new Date(), 'yyyy-MM-dd HH:mm:ss') const id = 'F' + RandomString(8) const sql = `insert into storage(id, md5, fullPath, updatedTime, isComplete, isDel) values(?, ?, ?, ?, 0, 0)` await query(sql, [id, identifier, `${targetPath}/${filename}`, now]) ctx.body = r.successData([]) } else { const ls = fs.readdirSync(chunkFolderURL) ctx.body = r.successData(ls) } } catch (e) { ctx.status = 501 ctx.body = r.error(306, e) }})


  • 分片上传

router.post('/upload', async ctx => { const { chunkNumber, identifier, filename, totalChunks, targetPath = '$Root' } = ctx.request.body const { file } = ctx.request.files const chunkFolderURL = `./public/storage-chunk/${identifier}` const chunkFileURL = `${chunkFolderURL}/${chunkNumber}` if (chunkNumber !== totalChunks) { const reader = fs.createReadStream(file.path) const upStream = fs.createWriteStream(chunkFileURL) reader.pipe(upStream) ctx.body = r.success() } else { const targetFile = `${targetPath}/${filename}`.replace('$Root', storageRootPath) fs.writeFileSync(targetFile, '') try { for (let i = 1; i <= totalChunks; i++) { const url = i == totalChunks ? file.path : `${chunkFolderURL}/${i}` const buffer = fs.readFileSync(url) fs.appendFileSync(targetFile, buffer) } const now = DateFormat(new Date(), 'yyyy-MM-dd HH:mm:ss') const sql = `update storage set isComplete = 1, updatedTime = ? where md5 = ?` await query(sql, [now, identifier]) ctx.body = r.success() deleteFolder(chunkFolderURL) logger('文件上传成功', 1, `targetFile: ${targetFile}, MD5:${identifier}, 切片源删除成功`) } catch (e) { ctx.status = 501 ctx.body = r.error(501, e) logger('文件合并失败', 0, `分片丢失 => ${e}`) fs.unlinkSync(targetFile) } }})


  • 获取回收站目录文件列表

// 获取回收站目录文件列表router.get('/getTrashList', async ctx => { const weekAgo = new Date().setDate(new Date().getDate() - 8) const trashFileSql = `select id, md5, fullPath, DATE_FORMAT(updatedTime, '%Y-%m-%d %H:%i:%s') updatedTime from storage where isDel = 1 and updatedTime > ?` const trashFileList = await query(trashFileSql, weekAgo) const trashFolderSql = `select id, folderName, fromPath, DATE_FORMAT(updatedTime, '%Y-%m-%d %H:%i:%s') updatedTime from trash_folder where updatedTime > ?` const trashFolderList = await query(trashFolderSql, weekAgo) if (!trashFileList || !trashFolderList) { ctx.body = r.error() return } let trashListMap = {} trashFileList.map(item => { const pathArr = item.fullPath.split('/') const fileRealName = pathArr[pathArr.length - 1] const fileNameArr = fileRealName.split('.') const suffix = fileNameArr.length > 1 ? fileNameArr[fileNameArr.length - 1] : '' const fileName = `${item.id}.${suffix}` trashListMap[fileName] = { fileName, showFileName: fileRealName, fromPath: item.fullPath, updatedTime: item.updatedTime, isFolder: false } }) trashFolderList.map(item => { const folderNameArr = item.folderName.split('/') const fileName = folderNameArr[folderNameArr.length - 1] trashListMap[fileName] = { fileName, fromPath: item.fromPath, updatedTime: item.updatedTime, isFolder: true } }) const ls = fs.readdirSync(storageTrashPath) const result = ls.map(item => { if (trashListMap[item]) { return trashListMap[item] } else { const arr = item.split('.') const fileName = arr.length > 1 ? arr.slice(0, arr.length - 1).join('.') : arr[0] const a = fileName.substr(-14) return { fileName: item, updatedTime: `${a.substr(0, 4)}-${a.substr(4, 2)}-${a.substr(6, 2)} ${a.substr(8, 2)}:${a.substr(10, 2)}:${a.substr(12, 2)}` } } }) ctx.body = r.successData(result)})


  • 回收站文件还原

router.post('/restore', async ctx => { let { restoreList } = ctx.request.body if (!restoreList || restoreList.length === 0) { ctx.body = r.parameterError(); return } try { let sql = '' let paramsArr = [] await Promise.all( restoreList.map(item => { const oldPath = `${storageTrashPath}/${item.fileName}` const restorePath = item.fromPath.replace('$Root', storageRootPath) fs.renameSync(oldPath, restorePath) if (item.isFolder) { sql += `delete from trash_folder where folderName = ?;` paramsArr.push(item.fileName) } else { const id = item.fileName.split('.')[0] sql += `update storage set isDel = 0 where id = ?;` paramsArr.push(id) } }) ) await transactionQuery(sql, paramsArr) ctx.body = r.success() logger('还原文件', 1, `${restoreList.map(item => item.fileName).join(',')}`) } catch (e) { ctx.body = r.error(e) logger('还原文件', 0, e.toString()) }})

  • 回收站文件永久删除

router.post('/permanentlyDelete', async ctx => { let { deleteList } = ctx.request.body if (!deleteList || deleteList.length === 0) { ctx.body = r.parameterError(); return } try { let sql = '' let paramsArr = [] await Promise.all( deleteList.map(item => { const oldPath = `${storageTrashPath}/${item.fileName}` if (item.isFolder) { deleteFolder(oldPath) } else { fs.unlinkSync(oldPath) } if (item.isFolder) { sql += `delete from trash_folder where folderName = ?;` paramsArr.push(item.fileName) } else { const id = item.fileName.split('.')[0] sql += `delete from storage where id = ?;` paramsArr.push(id) } }) ) await transactionQuery(sql, paramsArr) ctx.body = r.success() logger('永久删除文件', 1, `${deleteList.map(item => item.fileName).join(',')}`) } catch (e) { ctx.body = r.error(e) logger('永久删除文件', 0, e.toString()) }})

该系统前端Git:https://github.com/leon-kfd/FileSystem

来源:https://juejin.cn/post/6972727914030858248



程序员技术交流群

有不少同学给鸭哥说,现在进大厂太难了!赚钱太难!因此,鸭哥特意邀请了华为、腾讯、阿里的朋友进群,与大家一起交流经验,一起增长技术。

有兴趣入群的同学,可长按扫描下方二维码,一定要备注:城市+昵称+技术方向,根据格式备注,可更快被通过且邀请进群。

▲长按扫描


近期技术热文

1、“要源码上门自取”,结果人真上门了!国内企业再惹争议

2、Java 最常见的 208 道面试题:第三模块答案

3、老板:你花了两天时间才加了两行代码?我:无语~

4、Java 最常见的 208 道面试题:第二模块答案

点击下方公众号
回复关键字【666
领取资料


我就知道你会点赞+“在看”

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存